对算法的了解一直很肤浅(听学数学的朋友说算法在数学中也叫数论?),本书阅读不求快,本就是入门读物,希望能尽量理解,争取早日拿下。
这边值得一提的是作者推荐了一个网站,可汗学院,
khanacademy.org
mark一下。看完40%来总结一下,非常好,文盲也能看懂的算法入门。
这本书看完应该会扫一眼结城浩的《图解密码学》
第一章 算法简介
1.1 引言
好的,我具备了
1.2 二分查找
二分查找(binary search)又叫折半搜索(half-interval search)、对数搜索(logarithmic search),是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
对数:幂运算的逆运算
假设你要在字典中查找一个单词,而该字典包含240000个单词,
你认为每种查找最多需要多少步?
log2 n步,本题中就是18步
给定一个有序数组和一个需要定位的数字,先创建两个变量 low 和 high,low和high一开始分别是数组的第一个和最后一个坐标,划定一个取中间元素的空间,然后取出中间元素和目标元素比较,如果不是的话,就更改low或者high中某一个的坐标为(low + high)/2,将查找空间缩小为原来的二分之一,然后继续。
1 | def binary_search(list, item): |
Tips:关于为什么更换搜索区域的时候没有直接用high = mid 或者low = mid
注意while的条件,如果没有这一条,范围缩小到两个数的时候,会无限循环
运行时间
最多猜测次数与列表长度相同被称为线性时间(linear time).
二分查找的运行时间为对数时间(log time).
1.3 大 O 表示法
1 | O(1): 常量时间,哈希 |
算法的速度指的并非时间,而是操作数的增速。
谈论算法的速度时,我们说的是随着输入的增加,其运行时间将以什么样的速度增加。
算法的运行时间用大O表示法表示。
O(log n)比O(n)快,当需要搜索的元素越多时,前者比后者快得越多。
旅行商问题
行商问题(最短路径问题)(英语:travelling salesman problem, TSP)是这样一个问题:给定一系列城市和每对城市之间的距离,求解访问每一座城市一次并回到起始城市的最短回路。它是组合优化中的一个NP困难问题,在运筹学和理论计算机科学中非常重要。
第二章 选择排序
2.1 内存工作原理
2.2 数组和链表
链表:不需要移动元素,优势在插入元素
使用链表在中间插入元素只需要修改前面一个元素指向的地址,因此当需要在中间插入的时候,链表是更好的选择。
删除也是一样
数组和链表的运行时间:
数组 | 链表 | |
---|---|---|
读取 | O(1) | O(n) |
插入 | O(n) | O(1) |
删除 | O(n) | O(1) |
有两种访问方式:随机访问和顺序访问。
顺序访问意味着从第一个元素开始逐个读取元素,链表只能顺序访问,数组支持随机访问,所以数组在需要随机访问的情况下用得很多。
2.3 选择排序
时间复杂度的Tips
1 | def findSmallest(arr): |
1 | def selection(arr): |
Tips:
python之间的语法不兼容是很蛋疼的事情
py2:
1 | print "Pyhon 2 can use print string without ()"; |
py3:
1 | print("Python3, print must use () to output string"); |
py3中,print作为函数必须要带括号
第三章 递归
递归:优雅的问题解决办法
3.1 递归
“如果使用循环,程序的性能可能更高;如果使用递归,程序可能 更容易理解。如何选择要看什么对你来说更重要“
3.2 基线条件和递归条件
递归条件(base case)是指函数调用自己,基线条件(recursive case)是指函数不再调用自己,从而表面形成无限循环。
1 | def countdown(i): |
3.3 栈
调用栈(call stack)
python虽然不是用的JVM 但是 对于栈内存的调用 好像都差不多
递归函数factorial(5)写作5!
意义是5! = 5 * 4 * 3 * 2 * 1
使用栈虽然很方便但是也要付出代价:占用大量内存
第四章 快速排序
4.1 分而治之
一种著名的递归式问题解决方法—divide and conquer,D&C
重要的D&C是算法:快排,优雅代码的典范
欧几里得算法(辗转相除法):gcd(a.b) = gcd(b, a%b)
大的那个数 | 小的那个数 | 余数 | 商 |
---|---|---|---|
a | b | r0 = a%b | q0 |
b | r0 | r1 = b% r0 | q1 |
r0 | r1 | r2 = r0 % r1 | q2 |
… | … | … | … |
rN-4 | rN-3 | rN-2 = rN-4 % rN-3 | qN-2 |
rN-3 | rN-2 | rN-1 = rN-3 % rN-2 | qN-1 |
rN-2 | rN-1 | rN = rN-2 % rN-1 | qN |
rN-1 | rN == 0 | rN-1 = 1 * rN-1 - 0 * rN | 0 |
得到的最大公约数就是rN-1
欧几里得算法的证明:
我个人觉得反证法比较好理解:
要证欧几里德算法成立,即证: gcd(a,b)=gcd(b,r),其中 gcd是取最大公约数的意思,r=a mod b
下面证 gcd(a,b)=gcd(b,r)
设 c是a,b的最大公约数,即c=gcd(a,b),则有 a=mc,b=nc,其中m,n为正整数,且m,n互为质数
由 r= a mod b可知,r= a- qb 其中,q是正整数,
则 r=a-qb=mc-qnc=(m-qn)c
b=nc,r=(m-qn)c,且n,(m-qn)互质(假设n,m-qn不互质,则n=xd, m-qn=yd 其中x,y,d都是正整数,且d>1
则a=mc=(qx+y)dc, b=xdc,这时a,b 的最大公约数变成dc,与前提矛盾,所以n ,m-qn一定互质)
则gcd(b,r)=c=gcd(a,b)
得证。
编写涉及数组的递归函数时,基线条件通常是数组为空或只包含一个元素。陷入困境时, 请检查基线条件是不是这样的。
4.2 快排
快排使用了D&C
思路:
基线条件:数组为空或者只包含一个元素。这种情况下,只需要原样返回。
对于两个元素的数组:如果第一个元素比第二个元素小,直接返回,如果不是,就交换位置。
三个元素的数组:
从数组中选择一个元素,这个元素被称为基准值(pivot),
我们暂时先将数组的第一个元素作为基准值。
接下来找出比基准值小的元素以及比他大的元素。这个过程被称为分区(partition)
这里两个分区出来的数组时无需的,但是如果这两个数组是有序的,对整个数组进行排序将非常容易。
那么问题就转化成了如何对子数组进行排序,
这里我们讨论的是特定情况(三个元素),无论选用哪个元素作为pivot,剩下的情况总能用上面两个元素数组的排序方法代入。
于是就得到了解决办法
接下来四个元素的情况,类似的。
1 | def quicksort(array): |
这边可以对比一下时间复杂度
选择排序的时间复杂度是O(n2)
快速排序的时间复杂度最差是O(n2),平均情况是O(n log n)
还有一种合并排序(merge sort)运行时间是O(n log n)
现在做出一个有趣的假设,假设简单查找每次需要10ms,二分查找的常量是1s,现在我们假设查找的元素个数是10个,简单查找需要100ms,二分查找却需要log 10 * 1s,可以二分查找的时间远大于简单查找,但是我们查找的元素很大时,比如40亿,这个时候我们使用简单查找需要463天,但是二分查找只要32s。
通过这个例子,我们可以看到常量的影响可能会很大。
我们再来看快排,快排的效率取决于选择的pivot,当pivot是最小值的时候,我们其实只用到的一个数组,要递归很多次才能递归结束,这种情况是最坏的情况
如果我们选择的是中间值,是最佳情况,这种情况下根本不要这么多递归,因此调用栈就短得多。
需要注意的是,我们并不是如图这么简单的+n次调用,作为递归二次每次调用栈都设计O(n),这是递归的性质决定的。
因此,实际上最佳情况是O(n log n)
最佳情况也是平均情况(和最佳情况在同一数量级所以忽略掉前面的参数,剩下的相同),快排是D&G的典范。
第五章 散列表 Hash Table
散列表是足有用的基本数据结构之一。
虽然二分法的效率已经可以了,但是能不能有一种查找方法的查找时间是O(1)呢——任意给出一个查找内容,都能立即给出答案。
5.1 散列函数
散列函数应该满足的要求:
- 他必须是一致的。例如:假设你输入apple时得到的是4,那么每次输入apple的时候,得到的都必须是4,如果不是这样,散列表将毫无用处。
- 它应该将不同的输入映射到不同的数字,如果一个散列函数不管输入是什么都返回1就不可以。最理想的情况是,将不同的输入映射到不同的数字。
原理:
- 散列函数总是将同样的输入映射到相同的索引。
- 不同输入映射到不同的索引。
- 散列函数知道数组有多大。
散列表是一种包含额外逻辑的数据结构。
散列表又被称为散列映射、映射、字典和关联数组。(Hash Table)
python提供的散列表实现为字典,可以使用dictt
来创建散列表。
python语法中
book = dict()
和book = {}
等价。
5.2 应用案例
电话簿
DNS解析(域名关联IP)DNS resolution
防止重复(比如抽奖、投票)
将案列表用作缓存
Facebook将主页、about页面,Contact页面、Terms 和 Conditions页面等众多页面通过页面URL映射到页面数据。
5.3 冲突 collision
大多数语言都提供了散列实现,冲突是指,两个键分配的数组位置相同,这是个问题。
解决办法:如果两个键映射到了同一个位置,就在这个位置存储一个链表。
但是,如果A开头的物品过多,散列表的效率将激素下降,然而:如果散列函数用的很好,这些列表就不会很长。
5.4 性能
平均情况下,散列表的查找速度和数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点!但是最糟的情况下,散列表的各种操作都很慢。
因此,为了避免冲突,需要有:
较低的填装因子。
良好的散列函数。
装填因子:
散列表包含的元素数目/位置总数
假设再散列表中存储100种商品的价格,散列表包含100个位置名最佳情况下,每个商品都将有自己的位置。
装填因子在大于1的情况下,需要在散列表中添加位置,这个操作被称为**调整长度(resizing)**。
一般操作是:数组增加一倍。
接下来,将所有元素用hash函数插入到新的散列表中。
平均而言,即便考虑到调整长度所需的时间,散列表操作所需的 时间也为O(1)。
良好的散列函数让数组中的值呈均匀分布。
糟糕的散列函数让值扎堆,导致大量的冲突。
第六章 广度优先搜索
图算法之广度优先搜索 (breadth-first search)
6.1 图简介
如图用来解决从A点到B点最短路径问题的办法叫图计算方法。
这种最短路径既可能是最短路径,也有可能是国际象棋中将对方将死的最少步数。
解决最短路径问题的算法被称为广度优先搜索。
6.2 图是什么
图用于模拟不同的东西是如何相连的。
6.3 广度优先搜索
书中的例计较简单,在朋友圈中找A,先遍历朋友,查找是否有A,有的话结束,没有的话,依次遍历朋友的朋友。(和之前找芒果经销商是一样的)
能够实现这种目的的数据结构叫做队列(queue)
队列的工作原理:你不能随机访问队列中的元素。队列只支持两种操作:入队和出队。
队列是一种先进先出(First In First Out,FIFO)的数据结构,而栈是一种后进先出(Last In First Out,LIFO)的数据结构。
6.4 实现图
python实现一个简单的图:
1 | graph = {} |
上图中的有向图和无向图是等价的。
6.5 实现算法
在Python中,可以使用函数deque来创建一个双端队列
这边需要考虑一个情况:就是朋友是朋友的朋友,循环调用会造成无限循环。
所以需要添加容错判断。用一个列表来记录检查过的人。
图的特殊情况:指针只往一个方向,比如说:族谱。
第七章 狄克斯特拉算法 Dijkstra’s Algorithm
7.1 狄克斯特拉算法介绍
依旧图的讨论。
如果之前的路径有了权重(节点到节点之间花费的时间不等价),重新计算最短路径,就应该使用狄克斯特拉算法。
狄克斯特拉算法包含四个步骤:
- 找出最便宜的节点,即可在最短时间内前往的节点。
- 对于该节点的邻居,检查是否有前往他们的最短路径,如果有,就更新其开销。
- 重复这个过程,知道对图中的每个节点都这样做了。
- 计算最终路径。
7.3 术语
每条边关联的数字叫做权重(weight)。
带权重的图称为加权图(weighted graph),不带权重的图称为非加权图(unweighted graph)。
要计算非加权图中的最短路径,可以使用广度优先搜索。
如果是为了计算加权图中的最短路径,可以使用迪克斯特拉算法。
图还可能有环,这意味着你可以从一个节点出发,走一圈后又回到这个节点
在无向图中,每条边都是一个环,狄克斯特拉算法只使用于有向无环图(DAG)
7.4 负权边
如果有负权边就不能用,就不能使用狄克斯特拉算法,因为负权边,就不能使用狄克斯特拉算法。
因为负权边会导致这种算法不管用。
因为:根据狄克斯特拉算法,没有比不支付任何费用获得海报更便宜的方式。
因此:不能将狄克斯特拉算法用于包含负权边的图。
要在包含负权边的图中,找出最短路径,可以使用另一种算法: 贝尔曼—福德算法(Bellman-Ford algorithm).
7.5 实现
可以用以上代码表示上图的散列表。
上面代码表达的表:
接下来需要一个散列表来粗春每个节点的开销。
节点的开销:从起点出发前往该节点需要的时间。
用表表示的话如图:
表中的无穷大可以这么表示:
除了上面两张表,还需要一个存储父节点的散列表:
创建这个散列表的代码如下:
最后,需要一个数组用于记录处理过的节点,因为对于同一个节点,你不用处理多次。
1 | processd = {} |
动图表示整个认证过程:
整体过程:
本书介绍的python 迪克斯特拉算法:
使用了三个散列表和一个数组,三个散列表的作用分别是:
第一个:Graph散列表
用来记录每个节点到指向节点的权重
第二个:Costs散列表
指的起点到某个节点的消耗
第三个:Parents散列表
指的是父节点的散列表
数组的作用是记录用于处理过的节点。
处理过程是,
找出一个未处理的节点(规则定位开销最小的)
然后在表一获得该节点的开销和邻居。
遍历邻居,
接着计算从起点到X再到邻居节点的距离,然后在表一中对比这样的开销和原先的开销大小,如果这样效率更高,那么在表二中替换掉(或者更新掉原先的数字),然后在表三中改变其父节点为X
(表二记载的开销是经过父节点的最短开销)
代码:
1 | node = find_lowest_cost_node(costs) |
书上对这个过程的描述还可以,但是我觉得如果能增加一个循环就更好了。
第八章 贪婪算法
8.1 教室调度问题
解决方法:
(1) 选出结束最早的课,它就是要在这间教室上的第一堂课。
(2) 接下来,必须选择第一堂课结束后才开始的课。同样,你选择结束最早的课,这将是要 在这间教室上的第二堂课。
重读这样做就能找出答案。
即:每步都选择局部最优解,最终得到的就是全局最优解。
此方法并非万能!但是行之有效,并且简单!